YownYang's blog

译《Effective Objective-C 2.0》第三章

这是翻译《Effective Objective-C 2.0》的第三章:接口和API设计

简介

一旦你构建过一个应用程序,那么你可能会想在将来的项目中重用部分代码。你甚至可能想要发布一些代码给他人使用。即使你认为你不会这样做,你也可能在别的地方用到。当你阅读过本节,它将帮你写出合适的接口声明。这意味着你需要理解各种缺陷,才能写出标准模板一样的Objective-C代码。

近年来,随着iOS的问世,带来了大量的开源社区和流行的组件,你经常会在自己的项目里使用别人的代码。相似的,别人也可能会使用你的代码,所以写出清晰的代码可以使别人更快、更容易的整合你的代码。并且谁也不知道,你写下的某个库会不会被成千上万的应用使用呢!

使用前缀名去避免命名空间冲突

不像别的语言,Objective-C没有命名空间功能。由于这个原因,如果没有采取措施去避免,命名冲突是非常容易出现的。由于符号重复编译错误,命名冲突对应用的影响是可能导致项目无法链接,例如:

1
2
3
4
5
6
duplicate symbol _OBJC_METACLASS_$_EOCTheClass in:
build/something.o
build/something_else.o
duplicate symbol _OBJC_CLASS_$_EOCTheClass in:
build/something.o
build/something_else.o

这个错误结果是因为某个符号在EOCTheClass类和其元类(看第14节)的符号表定义了两次。在应用程序的两部分代码中,同时存在EOCTheClass类的两个实现,那个符号也就自然会定义两次。

更糟糕的是,如果在链接时没有发现某个库包含了一份重复的代码,而是在运行时加载。在这种情况下,动态加载器将会遭遇重复符号错误并且导致应用程序崩溃。

仅有一种办法去避免这个问题就是去使用一个粗糙的命名空间:给所有的名字加上一个确定的前缀。这个前缀应该选择你公司名字或者应用名字或者两者结合。例如,如果你的公司名字叫做Effective Widgets,你可能决定在应用中使用EWS前缀,如果是EWB前缀,仅仅因为你的应用叫做Effective Browser。即使你加上了前缀也不会没有名称冲突,但是会减少发生的次数。

如果你使用Cocoa创建应用程序,需要注意苹果已经表明可能会使用任意两个字母作为前缀,所以在这种情况下,你应该选择三个字母做前缀。例如,如果你不遵守这项约定并使用了TW作为前缀,那么将会产生问题。当iOS 5.0的SDK发布时,它带来了Twitter框架,并且选择TW作为前缀,有一个叫做TWRequest的类用于支持Twitter APIHTTP请求。如果你的公司叫做Tiny Widgets,并且有你自己用的API,那么你很大可能会有一个叫做TWRequest的类。

在你的应用中,前缀不该带有类名但应该适用于所有的类。第25节讲述了如果类别基于存在的类,类别名字和方法名字前缀的重要性。另一个常被忽视的问题是与C函数的冲突或者你在类的实现文件中使用的全局变量。通常很容易忘记全局变量在编译后的目标文件中是作为顶级符号出现的。例如,在iOS SDK中的AudioToolbox框架,它有一个函数用于播放一个声音文件。你可以给它一个回调从而在它结束时调用它。你可能会去写一个类,当声音文件播放结束时,去调用它,像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// EOCSoundPlayer.h
#import <Foundation/Foundation.h>
@class EOCSoundPlayer;
@protocol EOCSoundPlayerDelegate <NSObject>
- (void)soundPlayerDidFinish:(EOCSoundPlayer*)player;
@end
@interface EOCSoundPlayer : NSObject
@property (nonatomic, weak) id <EOCSoundPlayerDelegate> delegate;
- (id)initWithURL:(NSURL *)url;
- (void)playSound;
@end
// EOCSoundPlayer.m
#import "EOCSoundPlayer.h"
#import <AudioToolbox/AudioToolbox.h>
void completion(SystemSoundID ssID, void *clientData) {
EOCSoundPlayer *player = (__bridge EOCSoundPlayer*)clientData;
if ([player.delegate respondsToSelector:@selector(soundPlayerDidFinish:)]) {
[player.delegate soundPlayerDidFinish:player];
}
}
@implementation EOCSoundPlayer {
SystemSoundID _systemSoundID;
}
- (id)initWithURL:(NSURL *)url {
if (self = [super init]) {
AudioServicesCreateSystemSoundID((__bridge CFURLRef)url,
&_systemSoundID);
}
return self;
}
- (void)dealloc {
AudioServicesDisposeSystemSoundID(_systemSoundID);
}
- (void)playSound {
AudioServicesAddSystemSoundCompletion(
_systemSoundID,
NULL,
NULL,
completion,
(__bridge void*)self);
AudioServicesPlaySystemSound(_systemSoundID);
}
@end

这看起来没什么问题,但是从这个目标文件的符号表中发现了一点不同,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
00000230 t -[EOCSoundPlayer .cxx_destruct]
0000014c t -[EOCSoundPlayer dealloc]
000001e0 t -[EOCSoundPlayer delegate]
0000009c t -[EOCSoundPlayer initWithURL:]
00000198 t -[EOCSoundPlayer playSound]
00000208 t -[EOCSoundPlayer setDelegate:]
00000b88 S _OBJC_CLASS_$_EOCSoundPlayer
00000bb8 S _OBJC_IVAR_$_EOCSoundPlayer._delegate
00000bb4 S _OBJC_IVAR_$_EOCSoundPlayer._systemSoundID
00000b9c S _OBJC_METACLASS_$_EOCSoundPlayer
00000000 T _completion
00000bf8 s l_OBJC_$_INSTANCE_METHODS_EOCSoundPlayer
00000c48 s l_OBJC_$_INSTANCE_VARIABLES_EOCSoundPlayer
00000c78 s l_OBJC_$_PROP_LIST_EOCSoundPlayer
00000c88 s l_OBJC_CLASS_RO_$_EOCSoundPlayer
00000bd0 s l_OBJC_METACLASS_RO_$_EOCSoundPlayer

注意符号表的中间部分,有一个符号叫做_completion。它是completion函数创建的用于在声音播放结束时做一些事情。即使它是在实现文件实现的,并且没有在头文件声明它,它依然作为顶级符号出现在这里。因此,如果某处创建的函数也叫做completion,那么将会出现一个错误,像下面这样的:

1
2
3
duplicate symbol _completion in:
build/EOCSoundPlayer.o
build/EOCAnotherClass.o

更糟糕的是如果你把库给别人用,他们在自己的应用中使用它。如果你暴漏了一个类似_completion的符号,任何使用你这个库的人都可能会创建一个叫做_completion的函数,这是非常不幸的。

所以你应该给它加上类似于C函数的前缀。例如,在之前的例子中,你可以将completion命名为EOCSoundPlayerCompletion。如果符号曾在回溯堆栈时出现,即使出现问题,也是异域排查的。

当你使用第三方库或者将你的代码制作成库给别人使用时,你要特别小心重复符号的问题。当你在你的应用程序中使用了第三方库时,重复符号错误是容易发生的。在这种情况下,通常会使用自己的前缀去给所有的第三方库加上前缀。例如,如果你的库叫做EOCLibrary并且你添加了一个叫做XYZLibrary的库,你将给XYZLibrary添加EOC前缀。然后应用程序使用XYZLibrary时,就没有命名冲突的机会了,如图3.1。

Figure 3.1 避免第三方库两次编译:一次应用程序本身另一次是库本身

仔细检查并更改所有的名字,看起来是件乏味的事情,但如果你想避免命名冲突,那是很有用的。你可能会问为什么需要这样做,并且为什么应用程序不能简单的包含XYZLibrary库本身并且使用它的实现。这也是可以的,但你考虑下面这个场景,你的应用程序包含另一个第三方库,叫做ABCLibrary,并且它也使用了XYZLibrary。在这种情况下,如果你和ABCLibrary库的作者都没有前缀,那么应用程序仍将发生重复符号的错误。或者你使用XYZLibrary的X版本,但是应用需要的功能是Y版本,那么它将会自动拷贝。如果你在开发的时候使用流行的第三方库,你将经常看到这种类型的前缀。

小结

  • 选择一个适合的前缀,可以是公司名,应用名,或者都可以。并且一直使用这个前缀。
  • 当你使用的第三方库依赖你自己的库,考虑给它的名字加上你的前缀。

提供指定的初始化器

所有的对象都需要初始化。当你初始化一个对象时,有时你不需要给他任何信息,有时需要。这种情况通常出现在没有信息就不能执行相应的方法的情况。例如,iOSUIKit框架中的UITableViewCell,组中不同类型的cell需要不同的类型和标示符,这样可以使用cell对象的复用功能,而不需要一直去创建。在初始化时,赋予对象执行任务所需的信息,在术语上称为指定初始化器。

一个类中有多种方法去创建实例,那么这个类可能会有多个初始化方法。这是很好的,但是应该其余方法调用指定的初始化方法。一个例子是NSDate,像下面这样的初始化方法:

1
2
3
4
5
6
- (id)init
- (id)initWithString:(NSString*)string
- (id)initWithTimeIntervalSinceNow:(NSTimeInterval)seconds
- (id)initWithTimeInterval:(NSTimeInterval)seconds sinceDate:(NSDate*)refDate
- (id)initWithTimeIntervalSinceReferenceDate: (NSTimeInterval)seconds
- (id)initWithTimeIntervalSince1970:(NSTimeInterval)seconds

上述情况中的指定初始化器是- (id)initWithTimeIntervalSinceReferenceDate:,类中的文档说明了这一点。它的意思是别的初始化方法其实都是调用了这个初始化方法。因此,指定的初始化器是存储内部数据的唯一地方。如果需要改变数据存储,那么仅需要改变这个方法就可以了。

例如,考虑一个代表范围的类。它的接口应该像下面这样:

1
2
3
4
5
#import <Foundation/Foundation.h>
@interface EOCRectangle : NSObject
@property (nonatomic, assign, readonly) float width;
@property (nonatomic, assign, readonly) float height;
@end

注意上述属性是只读(看第18节)的。这意味着矩形对象不能在外部修改它的属性。所以你可能创建一个这样的初始化方法:

1
2
3
4
5
6
7
8
9
- (id)initWithWidth:(float)width
andHeight:(float)height
{
if ((self = [super init])) {
_width = width;
_height = height;
}
return self;
}

但是如果某些调用使用[[EOCRectangle alloc] init]去创建实例呢?这样做是合法的,因为EOCRectangle的父类是NSObjectNSObject实现了一个叫做init的方法,它将所有的对象设置为0(或者是等价于0的数据类型)。如果这个方法被调用,那么EOCRectangle实例的宽和高都将为0。虽然这可能是你想要的,但是你可能更喜欢设置一个默认值,或者通过抛出异常告诉调用者,必须使用你的指定的初始化方法。在EOCRectangle这种情况下,它可能会这样覆盖init方法:

1
2
3
4
5
6
7
8
9
10
11
12
// Using default values
- (id)init {
return [self initWithWidth:5.0f andHeight:10.0f];
}
// Throwing an exception
- (id)init {
@throw [NSException
exceptionWithName:NSInternalInconsistencyException
reason:@"Must use initWithWidth:andHeight: instead."
userInfo:nil];
}

注意设置默认值的版本如何调用初始化方法的。它也可以通过直接设置_width_height两个实例变量。然而,如果类的存储发生了一些变化。例如,通过一个结构体去存储宽和高的值的集合,你将会需要修改两个方法的逻辑。在这个简单例子中,这还不算坏,但是想象下一个复杂的类有很多初始化方法和复杂的数据。那么将会很容易忘记修改其中的一个,从而导致冲突。

想象下,你现在想去给EOCRectangle创建一个叫做EOCSquare的子类。这种使用场景很常见,但是初始化器该怎么办?很明显,应该强制宽和高相等,因为它是一个正方形!所以你可能决定这样创建初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
#import "EOCRectangle.h"
@interface EOCSquare : EOCRectangle
- (id)initWithDimension:(float)dimension;
@end
@implementation EOCSquare
- (id)initWithDimension:(float)dimension {
return [super initWithWidth:dimension andHeight:dimension];
}
@end

它将变成EOCSquare的初始化器。注意它如何调用父类的指定初始化器。如果你往回看了EOCRectangle的指定初始化器,你将会看到它也调用了父类的指定初始化器。指定初始化器链条是重要的。然而,它仍然可能调用initWithWidth:andHeight:或者init方法去创建对象。你当然不想这样喽,因为有人可能会创建一个宽高不一致的正方形。这是一个重要的点在你子类化某个类时。在子类中如果你有一个不同名字的指定初始化器,你应该总是覆盖指定初始化器。在EOCSquare这种情况下,你可以覆盖EOCRectangle的指定初始化器:

1
2
3
4
- (id)initWithWidth:(float)width andHeight:(float)height {
float dimension = MAX(width, height);
return [self initWithDimension:dimension];
}

注意EOCSquare的指定初始化器是如何调用的。在这种实现情况下,如果调用者调用init方法,仍将产生神奇的事情。回想下EOCRectangle类,init方法的实现是去调用自身的指定初始化器,并且设置默认值。它仍然工作,但是因为initWithWidth:andHeight:方法已经被覆盖,所以它会调用EOCSquare的实现,它会依次调用初始化方法。这样一切正常,并不会创建一个宽高不等的正方形。

有时,你并不想去覆盖父类的指定初始化器,因为没有意义。例如,你可能会觉得一个EOCSquare对象使用initWithWidth:andHeight:方法创建很奇怪。你可能会认为它是一个使用错误。这种情况下,通常的做法是覆盖这个方法并且抛出一个异常:

1
2
3
4
5
6
- (id)initWithWidth:(float)width andHeight:(float)height {
@throw [NSException
exceptionWithName:NSInternalInconsistencyException
reason:@"Must use initWithDimension: instead."
userInfo:nil];
}

这看起来有点过于严格,但有时是必要的,这样可以保持内部数据的一致性。在EOCRectangleEOCSquare这种情况下,这意味着如果调用init方法会抛出错误,因为init方法会调用initWithWidth:andHeight:。这时,你可能会去重写init方法,并让其调用initWithDimension:

1
2
3
- (id)init {
return [self initWithDimension:5.0f];
}

然而,在Objective-C中,抛出异常代表这是一个致命错误(看21节),如果不能初始化实例,抛出异常应该是最后的选择。

在一些情况下,你可能需要多个指定初始化器。当对象可以以两种不同方式去初始化,那么就需要不止一个指定初始化器了。一个例子是NSCoding的协议,这是一种序列化机制,允许对象进行编码和解码。这种机制在AppkitUIKit中也是使用广泛的,这两个UI框架分别源于Mac OS XiOS,并且给对象提供用XML序列化NIB的能力,视图控制器控制解压缩。

NSCoding协议定义了序列化时应该实现下面的方法:

1
- (id)initWithCoder:(NSCoder*)decoder;

这种方法通常不是你的主要的指定初始化器,因为它还需要解码器去解码它。例外,如果父类也实现了NSCoding协议,那也需要调用父类的initWithCoder:方法。严格来说,你有两个指定初始化器,因为不止一个初始化方法调用父类的初始化方法。

应用到EOCRectangle类是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#import <Foundation/Foundation.h>
@interface EOCRectangle : NSObject <NSCoding>
@property (nonatomic, assign, readonly) float width;
@property (nonatomic, assign, readonly) float height;
- (id)initWithWidth:(float)width
andHeight:(float)height;
@end
@implementation EOCRectangle
// Designated initializer
- (id)initWithWidth:(float)width
andHeight:(float)height
{
if ((self = [super init])) {
_width = width;
_height = height;
}
return self;
}
// Superclass's designated initializer
- (id)init {
return [self initWithWidth:5.0f andHeight:10.0f];
}
// Initializer from NSCoding
- (id)initWithCoder:(NSCoder*)decoder {
// Call through to super's designated initializer
if ((self = [super init])) {
_width = [decoder decodeFloatForKey:@"width"];
_height = [decoder decodeFloatForKey:@"height"];
}
return self;
}
@end

注意NSCoding的初始化方法,它调用了父类的初始化而不是它自己的初始化。然而,如果父类也实现了NSCoding,它将调用NSCoding自身的指定初始化器。例如下面的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#import "EOCRectangle.h"
@interface EOCSquare : EOCRectangle
- (id)initWithDimension:(float)dimension;
@end
@implementation EOCSquare
// Designated initializer
- (id)initWithDimension:(float)dimension {
return [super initWithWidth:dimension andHeight:dimension];
}
// Superclass designated initializer
- (id)initWithWidth:(float)width andHeight:(float)height {
float dimension = MAX(width, height);
return [self initWithDimension:dimension];
}
// NSCoding designated initializer
- (id)initWithCoder:(NSCoder*)decoder {
if ((self = [super initWithCoder:decoder])) {
// EOCSquare's specific initializer
}
return self;
}
@end

所有的初始化方法都调用到父类的实现,即initWithCoder:。子类在初始化的任何事情之前调用它,先完成父类的初始化。这样,EOCSquare也可以完全兼容NSCoding协议。如果你是调用你自己的初始化方法或者别的父类的初始化方法,对于EOCSquare的实例来说,EOCRectangleinitWithCoder:方法永远不会被调用,并且宽高两个实例变量永远不会被解码。

小结

  • 在你的类中指定初始化器,并且用文档标明它。所有的别的初始化器都应该调用它。
  • 如果子类的初始化器不同于父类的初始化器,确保你覆写了父类的初始化器。
  • 当子类覆写了父类的初始化器,不应该抛出异常。

实现description方法

在调试的时候,你经常会输出一个对象来获得有用的信息。其中一个办法是输出对象的所有属性,通常像下面这样:

1
NSLog(@"object = %@", object);

当你以字符串方式输出对象时,这时对象将调用description方法并且替代%@符号。所以,如果输出对象是一个数组,大概是这样的:

1
2
NSArray *object = @[@"A string", @(123)];
NSLog(@"object = %@", object);

它的输出是:

1
2
3
4
object = (
"A string",
123
)

但是如果你尝试输出一个自己的类,你经常看到这样的结果:

1
object = <EOCPerson: 0x7fd9a1600600>

这样的输出是没有数组的输出有帮助的。除非你在你的类中覆写了description方法,否则只会调用NSObject的默认实现。这个方法定义在NSObject协议中,但是NSObject类实现了它。NSObject协议有许多方法,它这样做的原因是NSObject并不是唯一根类。例如NSProxy是另一个根类,它遵循NSObject协议。因为其余根类的子类也可能需要实现协议中的某些方法。如你所见,默认实现并没有太大用。它仅仅展示了对象的类名以及内存地址。如果你仅仅想知道两个对象是不是相同,那它是有用的。然而你可能更想知道它更多的信息。

为了输出有用的东西,你需要去覆写description方法并且返回你想知道的信息。例如,考虑下面的类的描述方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString*)firstName
lastName:(NSString*)lastName;
@end
@implementation EOCPerson
- (id)initWithFirstName:(NSString*)firstName
lastName:(NSString*)lastName {
if ((self = [super init])) {
_firstName = [firstName copy];
_lastName = [lastName copy];
}
return self;
}
@end

一个典型的description方法实现是这样的:

1
2
3
4
5
- (NSString *)description {
return [NSString stringWithFormat:@"<%@: %p, \"%@ %@\">",
[self class], self, _firstName, _lastName];
}

如果你像这样调用它,它将输出这些信息:

1
2
3
4
5
EOCPerson *person = [[EOCPerson alloc] initWithFirstName:@"Bob"
lastName:@"Smith"];
NSLog(@"person = %@", person);
// Output:
// person = <EOCPerson: 0x7fb249c030f0, "Bob Smith">

这样的输出很清晰,并且输出了很多有用的信息。我建议应该像默认实现那样,展示类名和指针地址,因为它有时候是有用的。尽管之前的NSArray并没有输出这些,并且也没有明文规定。不过你在description方法中输出的应该是所用到的。

有一个简单的办法去实现包含大量信息的description方法,那就是在返回中包含字典。它的返回值大概是这样的:

1
2
3
4
{
key: value;
foo: bar;
}

可以通过在自己的description方法中形成一个字典并返回包含此词典的字符串。例如,下面的类描述一个位置对象包含一个标题和经纬度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#import <Foundation/Foundation.h>
@interface EOCLocation : NSObject
@property (nonatomic, copy, readonly) NSString *title;
@property (nonatomic, assign, readonly) float latitude;
@property (nonatomic, assign, readonly) float longitude;
- (id)initWithTitle:(NSString*)title
latitude:(float)latitude
longitude:(float)longitude;
@end
@implementation EOCLocation
- (id)initWithTitle:(NSString*)title
latitude:(float)latitude
longitude:(float)longitude {
if ((self = [super init])) {
_title = [title copy];
_latitude = latitude;
_longitude = longitude;
}
return self;
}
@end

如果description方法能同时输出标题和经纬度就非常好了。如果使用字典,那么description方法可能像这样:

1
2
3
4
5
6
7
8
9
10
- (NSString *)description {
return [NSString stringWithFormat:@"<%@: %p, %@>",
[self class],
self,
@{@"title":_title,
@"latitude":@(_latitude),
@"longitude":@(_longitude)}
];
}

它的输出是这样的:

1
2
3
4
5
location = <EOCLocation: 0x7f98f2e01d20, {
latitude = "51.506";
longitude = 0;
title = London;
}>

这比刚才仅有指针和类名是有用多的,并且对象的所有属性都很好的展示了出来。你可以总是使用字符串去描述每个变量,但是当更多的属性被加入这个类,字典这种方法更易于操作。

NSObject协议中另一个与之类似的方法叫做debugDescription。它们的不同在于debugDescription是在调式器中输出对象时调用的。NSObject类默认实现就是调用description方法。例如,以EOCPerson类为例,在调试器中运行应用程序,并且断点在输出对象之后,像下面这样:

1
2
3
4
EOCPerson *person = [[EOCPerson alloc] initWithFirstName:@"Bob"
lastName:@"Smith"];
NSLog(@"person = %@", person);
// Breakpoint here

当断点触发时,控制台准备接收输出。在LLDB调试器中,使用po命令输出对象,像下面这样的:

1
2
3
EOCTest[640:c07] person = <EOCPerson: 0x712a4d0, "Bob Smith">
(lldb) po person
(EOCPerson *) $1 = 0x0712a4d0 <EOCPerson: 0x712a4d0, "Bob Smith">

注意,调试器中加上了一些额外的信息(EOCPerson *) $1 = 0x0712a4d0。后面的部分来源于debugDescription方法。

你可能只想在description方法中展示正常的人名,在debugDescription方法展示更深入的信息。这种情况下,这两个方法看起来是这样的:

1
2
3
4
5
6
7
8
- (NSString*)description {
return [NSString stringWithFormat:@"%@ %@",
_firstName, _lastName];
}
- (NSString*)debugDescription {
return [NSString stringWithFormat:@"<%@: %p, \"%@ %@\">",
[self class], self, _firstName, _lastName];
}

这次运行相同的代码,并且打印对象,输出如下:

1
2
3
EOCTest[640:c07] person = Bob Smith
(lldb) po person
(EOCPerson *) $1 = 0x07117fb0 <EOCPerson: 0x7117fb0, "Bob Smith">

这种做法是当你在正常调试时不需要看到类名、对象地址等额外信息,在调试器的环境下仍能轻松访问完整信息时使用。Foundation框架中的NSArray类就是一个很好的例子。例如:

1
2
3
NSArray *array = @[@"Effective Objective-C 2.0", @(123), @(YES)];
NSLog(@"array = %@", array);
// Breakpoint here

这时,运行程序,在断点处停止,并且输出数组对象:

1
2
3
4
5
6
7
8
9
10
11
EOCTest[713:c07] array = (
"Effective Objective-C 2.0",
123,
1
)
(lldb) po array
(NSArray *) $1 = 0x071275b0 <__NSArrayI 0x71275b0>(
Effective Objective-C 2.0,
123,
1
)

小结

  • 覆写description方法以提供实例的字符串描述。
  • 如果想要对象在调试器中做更多事,那么覆写debugDescription方法。

尽量使用不可变对象

在设计一个类时,理想情况下,考虑使用属性(看第6节)去存储数据。当使用属性时,你可以限制属性是只读的。默认情况下,属性是可读写的,这使你所有类都是可变的。然而,通常读取到数据之后是不需要改变的。例如,对象存储的数据来自只读的web service,如地图上的兴趣点列表,那么没有情况需要对象可变。如果这样的对象发生了改变,数据将不会被发送给服务器。如第8节所述,如果可变对象存储在集合中,则集合的内部数据结构很容易变得不一致。因此,我建议只在对象需要改变时,使用可变对象。

实际上,这意味着将外部属性设为只读,并且只暴漏需要暴漏的数据。例如,考虑一个类来处理地图上的兴趣点,这些数据来自web service。你可以像下面这样从某个类开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
#import <Foundation/Foundation.h>
@interface EOCPointOfInterest : NSObject
@property (nonatomic, copy) NSString *identifier;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, assign) float latitude;
@property (nonatomic, assign) float longitude;
- (id)initWithIdentifier:(NSString*)identifier
title:(NSString*)title
latitude:(float)latitude
longitude:(float)longitude;
@end

所有的数据都来自web service,标示符是服务端给一个兴趣点的标记。一旦兴趣点被创建,并且从服务端拿到数据之后,就不应该在任何场景下去修改。在别的语言中,你可能会创建一个私有变量,并且仅有一个getter方法。然而,在Objective-C中,当你使用属性时,这是非常容易的并且不需要考虑私有变量。

为了使EOCPointOfInterest类不可变,你可以给所有的属性添加只读特质:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#import <Foundation/Foundation.h>
@interface EOCPointOfInterest : NSObject
@property (nonatomic, copy, readonly) NSString *identifier;
@property (nonatomic, copy, readonly) NSString *title;
@property (nonatomic, assign, readonly) float latitude;
@property (nonatomic, assign, readonly) float longitude;
- (id)initWithIdentifier:(NSString*)identifier
title:(NSString*)title
latitude:(float)latitude
longitude:(float)longitude;
@end

这确保任何人试图修改某个属性值,编译时都会报错。这些属性的值还可以正常读取,但不能改变,所以EOCPointOfInterest数据不会产生不同。因此,任何人使用这个对象的人都可以确信数据不会被修改的。对象自身的数据结构也不会变的不一致。这种情况下,地图上显示的EOCPointOfInterest对象兴趣点的经纬度都不用担心被修改。

你可能会想为什么会有内存管理语义,因为它是只读的,是没有setter方法的。好吧,你可以简化上面的代码:

1
2
3
4
@property (nonatomic, readonly) NSString *identifier;
@property (nonatomic, readonly) NSString *title;
@property (nonatomic, readonly) float latitude;
@property (nonatomic, readonly) float longitude;

但是,使用内存管理语义是有用的,对你之后将属性特质设置为读写也是容易的。

你可能希望在对象内部对数据进行修改,而不是外部。在这种情况下,通常的做法是重新声明它为读写特质。当然,当属性特质是非原子性时,多个线程同时读写会造成权限的竞争。这是可能的,在一个观察者读取的同时,在内部修改这个属性。这种情况应该被杜绝,所有的访问无论是内部的还是外部的,或者是不同的队列(看第41节),都应该是同步的。

通过使用分类功能在类的内部将属性重新声明为读写特质。你可以像写在头文件一样重新声明它,只要拥有相同的特质和扩展的读写状态。在EOCPointOfInterest的例子中,类别中的声明可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#import "EOCPointOfInterest.h"
@interface EOCPointOfInterest ()
@property (nonatomic, copy, readwrite) NSString *identifier;
@property (nonatomic, copy, readwrite) NSString *title;
@property (nonatomic, assign, readwrite) float latitude;
@property (nonatomic, assign, readwrite) float longitude;
@end
@implementation EOCPointOfInterest
/* ... */
@end

现在,这属性可以在EOCPointOfInterest类的实现内部进行修改了。更准确的说,通过使用KVC这种方法,在外部也可以对对象进行修改,例如下面这种:

1
[pointOfInterest setValue:@"abc" forKey:@"identifier"];

这是因为KVC是直接调用的identifiersetter方法,即使你这个方法并没有暴漏在头文件。然而,这样做被视为对类API的非法入侵,如果有什么问题,还是需要开发者自身去解决。

一个不讲道理的开发者可以通过在类上使用内省来确定类对象的内存布局中属性的实例变量的偏移量而不是setter方法。开发者可以通过这种办法去设置实例变量,但这种行为被视为对类API的非法入侵。但从技术上来说,围绕缺失了头文件的setter方法这种可能性,你不应该忽略使你的对象是不可变的。

在定义类的公共API时,要记住的另一点是集合类属性是否是可变的或不可变的。例如,你有一个代表人的类,并且可以存储这个人的朋友列表,你可能想使用一个属性去存储这个人的朋友列表。如果这个人的朋友会添加和删除,那么这个属性应该设置为可变的。在这种情况下,应该暴漏一个只读特质并且不可变的集合,但它其实是拷贝了内部的可变集合。例如,像下面类的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// EOCPerson.h
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSSet *friends;
- (id)initWithFirstName:(NSString*)firstName andLastName:(NSString*)lastName;
- (void)addFriend:(EOCPerson*)person;
- (void)removeFriend:(EOCPerson*)person;
@end
// EOCPerson.m
#import "EOCPerson.h"
@interface EOCPerson ()
@property (nonatomic, copy, readwrite) NSString *firstName;
@property (nonatomic, copy, readwrite) NSString *lastName;
@end
@implementation EOCPerson {
NSMutableSet *_internalFriends;
}
- (NSSet*)friends {
return [_internalFriends copy];
}
- (void)addFriend:(EOCPerson*)person {
[_internalFriends addObject:person];
}
- (void)removeFriend:(EOCPerson*)person {
[_internalFriends removeObject:person];
}
- (id)initWithFirstName:(NSString*)firstName andLastName:(NSString*)lastName {
if ((self = [super init])) {
_firstName = firstName;
_lastName = lastName;
_internalFriends = [NSMutableSet new];
}
return self;
}
@end

你也可以直接将friends属性设置为可变集合并通过直接操作集合去删除和增加朋友,而不是通过addFriend:removeFriend:方法。但是这样数据就能随意修改,容易产生bug。如果EOCPerson类中的朋友集合可以被外部改变,这可能会造成一些问题。例如,当添加朋友或者删除朋友时,这个对象想做一些别的事情,这时,这个对象就会变得不一致。

在这点上,同样重要的是不要给你反回的对象做内省,去判断它是否是可变的。例如,你可能使用一个包含EOCPerson类的库。库的开发者可能没有返回一个内部可变集合的拷贝,而是直接返回了可变集合本身。如果这个集合非常大,这是合法合理的,因为拷贝的代价太大。它返回一个NSMutableSet是合法的,因为它是NSSet的子类,在这种情况下,你可能会这样写:

1
2
3
4
5
6
EOCPerson *person = /* ... */;
NSSet *friends = person.friends;
if ([friends isKindOfClass:[NSMutableSet class]]) {
NSMutableSet *mutableFriends = (NSMutableSet*)friends;
/* mutate the set */
}

无论如何,你应该避免这种写法。你并没有跟EOCPerson类预定什么,所以你不应该在此处使用内省。重点是,这个对象可能没办法处理你要的操作。因此,你不应该假设它可以。

小结

  • 尽量创建不可变对象。
  • 如果属性需要在内部设置,那么暴露给外部的应该设置为只读,在类别中设置为读写。
  • 通过方法去操作可变集合,而不是直接暴漏可变集合本身。

使用清晰而协调的命名方式

Objective-C中,类的命名,方法的命名,变量的命名等等都是重要的因素。新手总是说语言冗长,因为使用的语法结构可以像自然语言一样阅读。命名的时候常常包括一些介词(in、for、with等等),而别的语言经常忽略这些。例如,考虑下面的代码段:

1
2
NSString *text = @"The quick brown fox jumped over the lazy dog";
NSString *newText = [text stringByReplacingOccurrencesOfString:@"fox" withString:@"cat"];

上述代码经常被认为是把一个简单的表达式复杂成了一句啰嗦的话。毕竟,执行的替换方法长达48个字符。但是它读起来像一句话:“使用cat字符串替换text字符串中的fox字符串并且赋值给一个新的字符串。”

这句话完美的表达了正在发生的事情。在不冗长的语言中,大概是这样的:

1
2
string text = "The quick brown fox jumped over the lazy dog";
string newText = text.replace("fox", "cat");

但是上面的命令中,text.replace的参数是什么意思?fox字符串替代cat,还是相反?而且,替换函数替换所有字符还是第一个?这非常不清晰。虽然Objective-C的语法更长,但是却非常清晰。

你还会注意到不论是变量名还是方法名,使用的都是首字母小写的骆驼式命名法。另外,类名是以大写字母开头并且带有两个或者三个字符的前缀(看第15节)。这个风格遍布整个Objective-C代码。如果你愿意你可以使用你自己的风格,但是在Objective-C中骆驼风格将确保你命名的健壮。

方法命名

如果你之前学过别的语言,例如C++或者Java,你习惯于函数命名的简洁,并且必须查看函数原型以确定参数做什么。然而,这使得代码难以阅读,因为你经常需要返回原型来记住函数的作用。例如,考虑一个代表范围的类。在C++中,你可能这样定义它:

1
2
3
4
5
6
7
8
9
class Rectangle {
public:
Rectangle(float width, float height);
float getWidth();
float getHeight();
private:
float width;
float height;
};

如果你不熟悉C++,那也没关系。你只需要意识到有一个叫做Rectangle的类,里面有两个实例变量,宽和高。它也有一个接受宽和高的方法用于创建类的实例,叫做构造器。它也有宽高的访问方法。当使用这个类时,你会这样创建实例:

1
Rectangle *aRectangle = new Rectangle(5.0f, 10.0f);

当你回头看这些代码时,你不能清楚的知道5.0f10.0f代表什么。你大概可以猜到它是构成矩形的宽高,但你知道第一个参数是宽还是高?你需要回头去看看定义的构造方法。

Objective-C通过更长的方法命名解决了这个问题。与上述C++代码等价的Objective-C代码如下:

1
2
3
4
5
6
7
8
9
10
#import <Foundation/Foundation.h>
@interface EOCRectangle : NSObject
@property (nonatomic, assign, readonly) float width;
@property (nonatomic, assign, readonly) float height;
- (id)initWithSize:(float)width :(float)height;
@end

这样的写法可以轻松的知道等价的构造器方法和方法名叫做initWithSize:。你可能认为它很奇怪或者在第二个参数的冒号前面没有字符是语法错误的。实际上,语法是非常合理的,但它犯了和C++函数命名一样的毛病。如果你使用这个类,你会在相同的位置看到这个问题:

1
2
EOCRectangle *aRectangle =
[[EOCRectangle alloc] initWithSize:5.0f :10.0f];

一个更好的写法是像下面这样的:

1
- (id)initWithWidth:(float)width andHeight:(float)height;

这是冗长的,但对于使用时,每个变量的意思都是清晰的:

1
EOCRectangle *aRectangle = [[EOCRectangle alloc] initWithWidth:5.0f andHeight:10.0f];

新手往往很难使用Objective-C冗长的命名,尽管冗长的命名可以增加代码的可读性。不要害怕使用长的方法名。确保方法名是他们需要表达的,但不是让你使用极长的命名。你的方法名应该统一和清晰。

EOCRectangle类为例子。好的方法命名像下面这样:

1
2
- (EOCRectangle*)unionRectangle:(EOCRectangle*)rectangle
- (float)area

不好的的方法命名像下面这样:

1
2
- (EOCRectangle*)union:(EOCRectangle*)rectangle // Unclear
- (float)calculateTheArea // Too verbose

清楚地方法命名就像读一篇文章一样,从左到右阅读。遵循方法命名规则并不是强制的,但这样做将会确保你的代码易于维护和被其他人阅读。

NSString类是一个好的命名示例,它遵循了良好的命名规则。这有一些它的方法以及为什么这样命名的解释:

+ string

这是一个工厂方法,用于创建一个新的空字符串。通过方法名表示返回值。

+ stringWithString:

这是一个工厂方法,使用另一个字符串创建一个新的字符串。同创建空的字符串的工厂方法一样,它通过方法名的第一个单词表示返回值。

+ localizedStringWithFormat:

这是一个工厂方法,使用指定的格式去创建一个本地化的字符串。它的返回值是方法名的第二个单词,这是因为对返回类型进行修饰是合理的。尽管它返回的仍然是字符串,但它是一种更具体的字符串,因为它已经本地化了。

- lowercaseString

将一个字符的所有字符转化为小写。它创建了一个新的字符串而不是转化者本身,因此它遵循返回类型作为方法名的一部分的规则。不过修饰符仍应在类型之前。

- intValue

将字符串解析为整数。因为它的返回类型是int,所以它的第一个单词是int。通常你不会缩写类型。例如string不会缩写成strint是类型名字,所以方法名的后缀带有value而不是单一单词。单一单词通常用在属性上面。因为int不是属性,所以添加value限制它。

- length

获取字符串的长度。这是一个单独的短语,因为它实际上是一个字符串的属性。对于这个方法有一个不好的名字,叫做stringLengthstring这个单词是多余的,因为这个方法的接收者本身就是个字符串。

- lengthOfBytesUsingEncoding:

获取使用给定编码方式编码的字节数组长度。这与length方法类似,所以可以用同样的理由解释。另外,这个方法需要一个参数。方法名称在描述其类型的名词之后立即放置参数。

- getCharacters:range:

在字符串的给定范围内获取单个字符。这是一个例子,因为这不是一个访问方法,所以添加get前缀,不像一些其他的语言。这里使用的原因是字符是通过作为第一个参数传入的数组返回的。完整的方法签名如下:

- (void)getCharacters:(unichar*)buffer range:(NSRange)aRange

第一个参数,缓冲区,应该是指向足够容纳所请求范围内字符的数组的指针。该方法通过一个参数(通常称为out-parameter)返回,而不是通过返回值,因为它从内存管理角度更有意义。该方法的调用者处理所有内存管理,而不是由方法执行创建,并要求调用方释放它。第二个参数是以名词描述其类型,就像正常参数一样。有时,这些参数名前面都有一个介词;例如,这个方法可以叫做getCharacters:inRange:。如果参数超过其他参数需要额外的意义,通常是这样做的。

- hasPrefix:

确定字符串前缀是否是给定字符。它的返回值是一个Bool值,所以通常这样使用它,像读句子一样。例如:

1
[@"Effective Objective-C" hasPrefix:@"Effective"] == YES

如果方法名是prefix:,它不容易理解的。相似的,如果是isPrefixedWith:,它是太长的并且听起来也过于笨拙。

- isEqualToString:

确定两个字符串是否相等。它的返回值是一个Bool值,就像上面的hasPrefix:方法一样,方法的名称确保该方法像句子一样阅读。另一个使用is前缀的地方是Bool属性。如果属性名是enabled,例如它的访问方法是setEnabled:isEnabled。总之,遵循一些规则将帮助你给方法命名。

  • 如果方法返回一个新值,那么方法的第一个单词应该是它的类型,除非它需要一个修饰词,例如localizedString方法。这个规则不适用于属性访问器,因为他们在逻辑上没有创建一个新的对象。即使它们可能会返回一个拷贝的内部对象。这些访问器方法代表属性本身。
  • 一个参数应该紧挨着一个描述它类型的名词。
  • 如果一个对象需要参数来执行操作,那么导致对象上发生动作的方法应该包含一个动词,然后是一个名词(或多个名词)。
  • 不要使用缩写,例如str,使用全名,例如string
  • Bool属性的前缀应该使用is。方法返回一个Bool值但不应该直接返回属性应该带有has或者is前缀,这取决于你使用的场景。
  • 使用前缀get的方法,返回值应该是某个输出参数,例如填充C风格的数组。

类名和协议名

应该给类和协议提供前缀以避免命名空间冲突(看第15节)并且应该结构化,使它们从左到右阅读,就像方法一样。例如,NSArray类和它的对应可变类NSMutableArraymutable应该在array之前,因为它描述了一个指定的类型。

为了说明命名惯例,考虑下面UIKit中的类:

UIView (class)

所有的视图都继承自这个类。它们是用户界面的构建块,执行按钮、文本字段和表的绘制。类的名字是对它的解释以及整个UIKit框架的UI前缀。

UIViewController (class)

一个视图处理绘画视图但不负责控制在视图中显示。这就是这个类的工作:一个“视图控制器”。它以这样的方式命名,它保持左到右可读性。

UITableView (class)

这是一个特定的视图,用于显示列表数据。所以给父类名字添加特定前缀来区别视图的种类。在命名管理中,使用给父类加前缀是常见的。它可以被命名为UITable,但这样不能清除的指出它是一个视图。你需要查找接口声明以确定它是什么。如果你要创建一个表格视图用于显示图像,你可以创建一个子类叫做EOCImageTableView。例如,你总是使用自己的前缀,而不是父类的前缀。原因是你没有权利向另一个框架的命名空间添加一些东西,而另一个框架可能决定在将来创建一个同名的类。

UITableViewController (class)

正如表格是一种特定的视图,这是一种特殊的视图控制器专门设计用来控制表格视图。因此,它以类似的方式命名。

UITableViewDelegate (protocol)

该协议定义了一个接口,通过该接口,表格视图可以与另一个对象通信,并以它定义委托接口的类命名,从而保证了正确的可读性。(关于委托模式的更多信息,请参阅项目23)。

最重要的是,你应该使你的命名保持一致。另外,如果你的类在另一个框架,确保遵守命名约定。例如,你创建一个视图的子类,那么类名的后缀应该加上view。类似地,如果创建了自己的委托协议,则应该将它命名为它代表的类,带有Delegate后缀。坚持这种命名结构将确保当你或其他人稍后使用它时,你的代码是易懂的。

小结

  • 创建接口时,遵循Objective-C的命名规则,接口将会是健壮的。
  • 确保方法名称简洁而精确,并使其使用像阅读句子一样从左向右。
  • 在方法命中避免使用类型缩写。
  • 最重要的是,确保你自己的代码中方法名的一致性。

为私有方法名加前缀

一个类做的事情远比外部调用多的多。编写类实现时,通常会编写一些给类内部使用的方法。对于这样的方法,我建议给它们的名称加上前缀。通过将公共方法与私有方法区分开来,有助于调试。

给私有方法加标记的另一个原因是在修改方法名时易于区分。如果一个方法是公开的,那么修改它时应该加倍小心,因为可能对外部造成影响。因此,这个类的使用者也需要进行修改。但是,如果该方法是内部方法,则只有类自己的代码需要更改,对公开的API没有影响。对私有方法进行标记意味着在进行这种更改时很容易看出区别。

要使用什么样的前缀看你个人喜好,但我有一个好的选择是前缀包含一个字母p_。因为p代表private_在方法名开始之前有了视觉的差距。方法名仍继续使用骆驼命名法,即第一个字母小写。例如,一个叫做EOCObject的类,它的私有方法可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#import <Foundation/Foundation.h>
@interface EOCObject : NSObject
- (void)publicMethod;
@end
@implementation EOCObject
- (void)publicMethod { /* ... */
}
- (void)p_privateMethod { /* ... */
}
@end

与公共方法不同,私有方法不会出现在接口定义中。有时,您希望在类扩展中声明私有方法(看第27节);然而,最近的编译器修改意味着在使用方法之前不需要声明它。通常,私有方法只能在它们的实现中声明。

如果你是从C++或者Java转行过来的,你可能会很疑问为什么只加前缀,而不是声明它为私有方法。在目标Objective-C中,没有办法将方法标记为私有的。所有的对象都能相应所有的消息(看第12节),并且可以在运行时决定是否相应某个消息(看第14节)。Objective-C是在运行时执行给定消息的查找的,并且没有机制去限制什么东西、什么时候、什么范围影响消息。只剩下命名约定来指定语义,如私有方法。新手可能对这种方式感到不太舒服,但Objective-C就是这样一门语言,需要你去接受它的动态性和活力。但是动态性也是需要规则的,使用命名约定是实现这一目标的一种方法。

苹果倾向于使用单一下划线来作为它私有方法的前缀。所以你可能认为遵循苹果的提示并使用下划线是个好主意。然而,这有一个潜在的灾难性的问题;如果你继承了一个苹果的类,并在这个子类中使用了这种方法,你可能会无意中覆盖苹果的一个私有方法。基于这个原因,苹果已经说明了你应该避免使用下划线作为前缀。动态方法调度,从无法给方法指定作用域这点来看,这是不好的地方,但从另一方面看,它又是强大的。

覆盖苹果私有方法这种情况是常有的,例如,如果你正在创建一个iOS应用的视图控制器,你需要子类化UIViewController。视图控制器可以有很多状态,你需要一个清楚所有状态的方法,当你的控制器出现在屏幕上时,调用这个方法。因此,你可能会实现这样的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
#import <UIKit/UIKit.h>
@interface EOCViewController : UIViewController
@end
@implementation EOCViewController
- (void)_resetViewController {
// Reset state and views
}
@end

然而,UIViewController也实现了一个叫做_resetViewController的私有方法。从你写了这个私有方法开始,当你调用它时,总是会调用EOCViewController的这个方法,而不是UIViewController的。除非你深入了解这个库,否则你不会你知道这一点,因为这个方法并未暴漏出来。毕竟,这是一种用下划线表示的私有方法。在这种情况下,你的视图控制器可能会发生一些奇怪的事情,因为UIViewController的实现未被调用,或者你会诧异为什么这个方法的调用次数过于频繁。

总之,当你在一个既不是苹果也不是你自己的框架中对类进行子类化时,除非文档说明,否则你无法知道框架使用的什么私有前缀(如果有的话)。在这种情况下,你可以选择使用你的类前缀(看第15节)作为私有方法前缀,从而大大减少潜在冲突的风险。同样,你也应该考虑其他人也可能会子类化你的类。这就是为什么你应该为私有方法名添加前缀。如果没有实现的源代码,除非使用非常复杂的工具,否则没有办法找出类实现的私有方法。

小结

  • 给私有方法名添加前缀,这样很容易与公共方法区别开来。
  • 避免使用单个下划线作为方法前缀,因为这是由苹果使用的。

理解objective-c错误模型

许多现代语言,包括Objective-C,都有异常处理。如果你是一个java后台,你可能习惯于使用异常来处理错误情况。如果你习惯于异常处理任务,你需要忘记你知道的关于异常的一切并重新开始。

首先要注意的是,默认情况下异常机制在ARC(看第30节)下是不安全的。实际上,这个意思是任何该在异常作用域结束时释放的对象都不会被释放。当然你可以通过打开一个编译器标志使异常得到安全处理,但是这需要引入额外的代码,并且即使没有抛出异常,这部分额外代码也需要运行。这个编译器标志是-fobjc-arc-exceptions

即使不使用ARC,也很难写出不会引起内存泄露的安全代码。假设一个资源被创建并在不再需要时释放它。如果在资源释放之前抛出异常,则该资源将永远无法释放:

1
2
3
4
5
6
7
8
9
id someResource = /* ... */;
if ( /* check for error */ ) {
@throw [NSException exceptionWithName:@"ExceptionName"
reason:@"There was an error"
userInfo:nil];
}
[someResource doSomething];
[someResource release];

当然,解决这个问题的办法就是把释放代码放在抛出异常之前。但是,如果有很多的资源释放和更复杂的代码路径,代码很容易变得杂乱。另外,如果在这样的代码中添加了某些代码,那么在抛出异常之前,很容易忘记添加释放。

Objective-C现在使用的方法是只抛出重大异常,并且不需要再恢复了,即直接退出应用程序。这样就不用再考虑异常安全的代码了。

记住异常只能用于致命错误,例如当你创建了一个抽象基类,别人使用基类初始化或者未覆盖初始化方法时抛出异常。Objective-C不像其他语言一样,它没有构造方法一说。因此达到这个目的的最简单办法就是如果子类未重写父类必须重写的办法,那就抛出异常。任何试图使用基类创建实例的做法都将会抛出异常:

1
2
3
4
5
6
7
8
9
- (void)mustOverrideMethod {
NSString *reason = [NSString stringWithFormat:
@"%@ must be overridden",
NSStringFromSelector(_cmd)];
@throw [NSException
exceptionWithName:NSInternalInconsistencyException
reason:reason
userInfo:nil];
}

但是如果异常仅适用于致命错误,那么其他类型的错误呢?当错误发生时,Objective-C的通常选择是返回nil或者0,或者是使用NSError。有一个例子就是当初始化失败,返回nil或者0

1
2
3
4
5
6
7
8
9
10
- (id)initWithValue:(id)value {
if ((self = [super init])) {
if ( /* Value means instance can't be created */ ) {
self = nil;
} else {
// Initialize instance
}
}
return self;
}

在这种情况下,如果value值为空,则实例不能创建,将self设置为nil,并且返回nil。这样初始化的方法将知道发生了一个错误,因为没有实例被创建。

使用NSError可以提供许多的灵活性,因为可以将错误返回给调用者。一个NSError对象封装了三条信息:

Error domain (String)

错误产生的范围。这个通常是一个全局变量,用于表示错误的根源。例如,NSURL的处理系统,如果在获得数据时发生了错误,那么就会使用NSURLErrorDomain来表示。

Error code (Integer)

唯一的错误代码,用于指示错误域内具体的错误。通常,使用枚举来代表多种错误情况的集合。例如,HTTP请求失败时的HTTP状态码。

User info (Dictionary)

有关此错误的额外信息,例如一个本地化字符串或者导致该错误的别的错误信息,这样可以构成一个错误链。

在API设计中,NSError的第一种常见用法是通过委托协议传递。当错误发生时,该错误会通过协议的某个方法传递给调用者。例如,NSURLConnection的协议NSURLConnectionDelegate包含了下面的方法:

1
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error

当一个连接出现错误时,例如一个远程连接超时,就会调用此方法处理相关错误。这个代理方法不是必须实现的,这取决于使用者是否想知道有关错误。这比直接抛一个异常要好,因为它可以由使用者去决定是否实现。

另一个常用的使用方式是将NSError对象作为方法的一个参数。它看起来是这样的:

1
- (BOOL)doSomething:(NSError**)error

传递给方法的是一个指针,而这个指针又指向一个指针,这个才是指向NSError对象的。或者也可以把它当做一个直接指向NSError对象的指针。这样可以使方法在经由输出参数返回错误信息的同时还能返回一个普同的值。用法如下:

1
2
3
4
5
NSError *error = nil;
BOOL ret = [object doSomething:&error];
if (error) {
// There was an error
}

通常像这样返回错误的方法也会返回一个Bool值用来表示操作成功或者失败。如果你不关心错误信息,你可以直接判断Bool值,反之,你可以判断错误信息。当你不在乎错误信息时,你可以将它设置为nil。比如说,你可以这样写:

1
2
3
4
BOOL ret = [object doSomething:nil];
if (ret) {
// There was an error
}

实际上,当使用ARC时,编译器会自动将NSError**转化成NSError*__autoreleasing*;这意味着这个指针对象将会自动释放。这个对象必须自动释放,因为doSomething:这个方法不能保证调用者会释放NSError对象,所以必须加入autorelease。这与大部分方法的返回值语义相同了(以new、alloc、copy、mutableCopy开头的方法当然不在此列)。

方法通过输出参数返回错误类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (BOOL)doSomething:(NSError**)error {
// Do something that may cause an error
if ( /* there was an error */ ) {
if (error) {
// Pass the 'error' through the out-parameter
*error = [NSError errorWithDomain:domain code:code
userInfo:userInfo];
}
return NO; ///< Indicate failure
} else {
return YES; ///< Indicate success
}
}

通过使用*error语法,为错误参数解引用,这意味着错误参数所指的那个指针要指向新的NSError对象了。这个错误参数必须检测它是否为空,因为空指针解引用会导致段错误并且程序崩溃。因为调用者可能会将其设为空,所以必须判断这种情况。

NSError对象里的错误范围、错误码、额外的错误信息将根据错误的具体情况填入适当的内容。这使得调用者可以根据不同错误情况进行不同的处理。错误范围最好定义为一个全局常量字符串,错误码最好是枚举类型。例如,你可以这样定义它们:

1
2
3
4
5
6
7
8
9
10
11
// EOCErrors.h
extern NSString *const EOCErrorDomain;
typedef NS_ENUM(NSUInteger, EOCError) {
EOCErrorUnknown = 1,
EOCErrorInternalInconsistency = 100,
EOCErrorGeneralFault = 105,
EOCErrorBadInput = 500,
};
// EOCErrors.m
NSString *const EOCErrorDomain = @"EOCErrorDomain";

在你的库中创建一个错误范围是考虑周到的,因为它允许你创建并且返回一个NSError对象,使用者可以确定它来自你的库。为错误码创建一个枚举类型是好的主意,因为它记录具体的错误并且给代码一个有意义的名字。你甚至可以在头文件以注释形式定义更多更详细的错误信息。

小结

  • 仅应在发生致命错误导致程序崩溃时使用NSExceptions
  • 对于不致命的错误,提供一个协议方法处理错误或者传入一个NSError对象是好的办法。

理解NSCopying协议

我们经常会对一个对象就行拷贝。在Objective-C中,是通过copy方法进行拷贝的。而对类进行拷贝的方法是实现NSCopying协议,它只包含了一个方法:

1
- (id)copyWithZone:(NSZone*)zone

以前的空间是使用不同的段内存的并且创建对象都是一个确定的空间。现在,每一个应用都只有一个空间:默认空间。虽然你还需要实现这个协议方法,但是你不需要担心那个空间参数。

这个拷贝方法在NSObject实现了,但仅仅是通过默认空间调用了copyWithZone:。不止copy方法需要覆盖重写,copyWithZone:也需要覆盖重写。

为了让类支持拷贝,你需要遵循NSCopying协议,并且实现协议中唯一的方法。例如,有个表示人的类。在这个类的接口中,你需要声明你遵循NSCopying协议:

1
2
3
4
5
6
7
8
9
10
11
12
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject <NSCopying>
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString*)firstName
andLastName:(NSString*)lastName;
@end

然后,你需要实现这个协议的方法:

1
2
3
4
5
6
- (id)copyWithZone:(NSZone*)zone {
EOCPerson *copy = [[[self class] allocWithZone:zone]
initWithFirstName:_firstName
andLastName:_lastName];
return copy;
}

这个例子简单的使用初始化方法进行了复制。有时,你可能需要更进一步的工作,例如,你要拷贝的某个对象并没有在初始化方法中进行赋值。例如,EOCPerson类有一个数组用于表示这个人的朋友,并且通过一些方法去增加和删除其余的EOCPerson对象。在这种情况下,你还需要复制好友数组。下面是一个完整的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject <NSCopying>
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString*)firstName
andLastName:(NSString*)lastName;
- (void)addFriend:(EOCPerson*)person;
- (void)removeFriend:(EOCPerson*)person;
@end
@implementation EOCPerson {
NSMutableSet *_friends;
}
- (id)initWithFirstName:(NSString*)firstName andLastName:(NSString*)lastName {
if ((self = [super init])) {
_firstName = [firstName copy];
_lastName = [lastName copy];
_friends = [NSMutableSet new];
}
return self;
}
- (void)addFriend:(EOCPerson*)person {
[_friends addObject:person];
}
- (void)removeFriend:(EOCPerson*)person {
[_friends removeObject:person];
}
- (id)copyWithZone:(NSZone*)zone {
EOCPerson *copy = [[[self class] allocWithZone:zone]
initWithFirstName:_firstName
andLastName:_lastName];
copy->_friends = [_friends mutableCopy];
return copy;
}
@end

这一次,这个协议方法有了一些变化,增加了对_friends变量的拷贝。注意这里使用了->语法,因为这是一个内部变量。其实也可以声明为一个属性,但因为从未在外部使用,所以也就没什么必要了。

这个例子引出了一个有趣的问题:为什么要拷贝_friends变量?你可以不进行拷贝,这样每个对象都将使用相同的可变集合。但是这样做的话,当原始对象的_friends变量添加了一个朋友,那么所有拷贝的对象都将添加一个朋友。这显然不是你想要的。但是如果集合是不可变的,那么您可以选择不拷贝,反正集合不能修改,并且这样可以避免内存中存在两个完全一样的集合。

通常情况下,应该像本例这样,使用指定的初始化器去进行拷贝。但是有些时候不需要这样做,因为初始化方法有时候会产生一些副作用,比如一些无用的附加操作。比如,初始化方法可能会设置一个复杂的内部数据结构,并且这个数据结构马上要被别的数据所覆盖,那么就没必要进行初始化了。

如果你回头去看copyWithZone:方法,你会发现_friends是使用mutableCopy方法进行复制的。这个方法来自另一个协议,叫做NSMutableCopying。它的定义跟NSCopying协议是很相似的:

1
- (id)mutableCopyWithZone:(NSZone*)zone

mutableCopy就像上面的copy方法一样,都是使用默认空间调用的。如果你的类分为了可变和不可变两个版本,那么你也需要实现这个协议。当你这样使用时,你需要在copyWithZone:中返回一份不可变拷贝。不论你拷贝的对象是可变还是不可变,都应该在mutableCopyWithZone:中返回一份可变拷贝。类似的,如果你需要一份不可变拷贝,那么你应该调用copy方法。

下面的规则适用于可变数组和不可变数组的所有情况:

1
2
-[NSMutableArray copy] => NSArray
-[NSArray mutableCopy] => NSMutableArray

有一个微妙的情况需要注意,一个可变对象调用可copy方法会返回一个不可变实例。这样做可以很容易的在可变对象和不可变对象之间进行切换。另一种可以达到这个目的办法是使用三个方法:copyimmutableCopymutableCopycopy总是返回相同的类,其余两个方法返回特定的实例。但是如果使用者并不知道其所用实例是否可变,那么就不太好了。例如某个方法把可变对象当做不可变对象给了你,你使用这个对象调用copy。这时,你以为它是不可变对象但它其实是可变的。

你可以通过内省(看第14节)来确定对象的类型,但是这样会增加拷贝的复杂度。为了安全起见,你会只使用immutableCopymutableCopy,但是这样又回到了两个方法的情况。这和只有copymutableCopy是一样的。为什么会叫copy而不是immutableCopy呢,是因为这两个方法并不完全是为可变类和不可变类设计的,有些类是没有可变和不可变之分的。所以immutableCopy是一个坏名字。

拷贝还有一个问题是,进行的拷贝是深拷贝还是浅拷贝。深拷贝会对拷贝所有数据。通常,我们使用的容器类,都是浅拷贝,即只拷贝容器本身,不拷贝容器元素。这样做的主要原因是容器中可能含有无法拷贝的元素;另外,复制每个对象是不好的。图3.2展示了深拷贝和浅拷贝的区别。

Figure 3.2 深拷贝和浅拷贝的区别。浅拷贝的所有内容都指向原始内容。深拷贝的所有内容都指向拷贝后的内容。

通常,你希望自己的类遵循系统框架的拷贝模式,即使用copyWithZone:进行浅拷贝。但是如果需要,也可以添加一个深拷贝的方法。例如NSSet,它就在初始化时提供了一个方法进行深拷贝:

1
- (id)initWithSet:(NSArray*)array copyItems:(BOOL)copyItems

如果copyItemsYES,那么集合中的所有元素都会接收到拷贝的消息,然后以拷贝后的元素组成新的集合,并返回。

EOCPerson的例子中,朋友的集合是使用copyWithZone:进行拷贝的,根据上面内容得知,它们进行的是浅拷贝,不会逐个复制集合的元素。但是如果需要一个深拷贝,你可以提供这样一个方法:

1
2
3
4
5
6
7
8
9
10
- (id)deepCopy {
EOCPerson *copy = [[[self class] alloc]
initWithFirstName:_firstName
andLastName:_lastName];
copy->_friends = [[NSMutableSet alloc] initWithSet:_friends
copyItems:YES];
return copy;
}

没有实现深拷贝的协议,这个事情是留给你的每个类去做的。你只需要决定你是否提供深拷贝。另外,你也不应该假设NSCopying协议实现的是深拷贝。在绝大多数情况下,这是一个浅拷贝。如果你需要任何对象的深拷贝,除非有文档指出这个NSCopying协议实现的是深拷贝,要么找到相关方法,要么自己实现。

小结

  • 如果你的对象需要拷贝,那么你需要实现NSCopying协议。
  • 如果你需要可变和不可变两个版本,那么你需要实现NSCopyingNSMutableCopying协议。
  • 开发者可以选择使用深拷贝还是浅拷贝,但尽量使用浅拷贝。
  • 如果你的对象需要深拷贝,那么添加一个深拷贝方法。